Linked List
연결 리스트
노드가 포인터로 연결된 자료구조. 삽입/삭제 O(1), 접근 O(n). LRU 캐시, 실행 취소 기능에 활용.
연결 리스트
노드가 포인터로 연결된 자료구조. 삽입/삭제 O(1), 접근 O(n). LRU 캐시, 실행 취소 기능에 활용.
연결 리스트(Linked List)는 데이터를 저장하는 노드(Node)들이 포인터(Pointer)를 통해 서로 연결된 선형 자료구조입니다. 각 노드는 실제 데이터를 담는 데이터 필드와 다음 노드의 메모리 주소를 가리키는 포인터 필드로 구성됩니다. 배열과 달리 메모리상에 연속적으로 저장되지 않아, 동적으로 크기를 조절할 수 있습니다.
연결 리스트는 구조에 따라 세 가지 주요 종류로 나뉩니다. 단일 연결 리스트(Singly Linked List)는 각 노드가 다음 노드만 가리키며, 이중 연결 리스트(Doubly Linked List)는 이전 노드와 다음 노드 모두를 가리킵니다. 원형 연결 리스트(Circular Linked List)는 마지막 노드가 첫 번째 노드를 가리켜 순환 구조를 형성합니다.
연결 리스트와 배열의 가장 큰 차이는 시간 복잡도에 있습니다. 연결 리스트는 특정 위치에서의 삽입/삭제가 O(1)로 매우 빠르지만, 인덱스를 통한 임의 접근은 처음부터 순회해야 하므로 O(n)이 소요됩니다. 반면 배열은 인덱스 접근이 O(1)이지만, 중간 삽입/삭제 시 요소 이동으로 O(n)이 필요합니다.
실무에서 연결 리스트는 LRU(Least Recently Used) 캐시 구현에서 해시맵과 함께 사용되어 O(1) 시간에 캐시 항목을 추가, 삭제, 재배치합니다. 또한 텍스트 에디터의 실행 취소(Undo) 기능, 브라우저의 앞으로/뒤로 가기 히스토리, 음악 플레이어의 재생 목록 등 순차적 탐색과 빈번한 삽입/삭제가 필요한 곳에서 널리 활용됩니다.
| 연산 | 배열 (Array) | 연결 리스트 (Linked List) | 승자 |
|---|---|---|---|
| 인덱스 접근 | O(1) | O(n) | 배열 |
| 맨 앞 삽입 | O(n) | O(1) | 연결 리스트 |
| 맨 뒤 삽입 | O(1)* | O(1)** | 동등 |
| 중간 삽입 | O(n) | O(1)*** | 연결 리스트 |
| 검색 | O(n) | O(n) | 동등 |
| 메모리 효율 | 좋음 (연속 메모리) | 포인터 오버헤드 | 배열 |
* 동적 배열의 경우 평균 O(1), 최악 O(n) (리사이징 시)
** tail 포인터가 있는 경우
*** 삽입 위치의 노드 참조를 이미 가지고 있는 경우
# Python 연결 리스트 구현 예제
class Node:
"""연결 리스트의 노드"""
def __init__(self, data):
self.data = data # 데이터 저장
self.next = None # 다음 노드 포인터
self.prev = None # 이전 노드 포인터 (이중 연결 리스트용)
class LinkedList:
"""단일 연결 리스트 구현"""
def __init__(self):
self.head = None
self.tail = None
self._size = 0
def append(self, data):
"""맨 뒤에 노드 추가 - O(1)"""
new_node = Node(data)
if not self.head:
self.head = self.tail = new_node
else:
self.tail.next = new_node
self.tail = new_node
self._size += 1
def prepend(self, data):
"""맨 앞에 노드 추가 - O(1)"""
new_node = Node(data)
new_node.next = self.head
self.head = new_node
if not self.tail:
self.tail = new_node
self._size += 1
def delete(self, data):
"""값으로 노드 삭제 - O(n)"""
if not self.head:
return False
# 헤드 삭제의 경우
if self.head.data == data:
self.head = self.head.next
if not self.head:
self.tail = None
self._size -= 1
return True
# 중간/끝 노드 삭제
current = self.head
while current.next:
if current.next.data == data:
if current.next == self.tail:
self.tail = current
current.next = current.next.next
self._size -= 1
return True
current = current.next
return False
def find(self, data):
"""값으로 노드 검색 - O(n)"""
current = self.head
index = 0
while current:
if current.data == data:
return index
current = current.next
index += 1
return -1
def __len__(self):
return self._size
def __iter__(self):
current = self.head
while current:
yield current.data
current = current.next
def __repr__(self):
return " -> ".join(str(item) for item in self) + " -> None"
# 사용 예제
if __name__ == "__main__":
ll = LinkedList()
ll.append(1)
ll.append(2)
ll.append(3)
ll.prepend(0)
print(f"리스트: {ll}") # 0 -> 1 -> 2 -> 3 -> None
print(f"길이: {len(ll)}") # 4
print(f"2의 위치: {ll.find(2)}") # 2
ll.delete(2)
print(f"삭제 후: {ll}") # 0 -> 1 -> 3 -> None
"배열과 연결 리스트의 핵심 차이는 메모리 배치와 이에 따른 시간복잡도입니다. 배열은 연속 메모리라 인덱스 접근이 O(1)이지만, 중간 삽입 시 뒤 요소들을 전부 밀어야 해서 O(n)입니다. 연결 리스트는 반대로 포인터만 수정하면 되니 삽입/삭제가 O(1)이지만, 특정 위치까지 순회해야 하므로 접근은 O(n)이죠. 그래서 LRU 캐시처럼 빈번한 삽입/삭제가 필요한 곳에는 연결 리스트를, 랜덤 액세스가 많은 곳에는 배열을 씁니다."
"이 부분 LinkedList 대신 ArrayList로 바꾸는 게 좋겠어요. 데이터가 100만 개인데 연결 리스트는 노드당 포인터가 추가로 필요해서 메모리 오버헤드가 커요. 게다가 노드들이 메모리에 흩어져 있어서 CPU 캐시 적중률도 낮아지고요. 이 케이스는 순차 접근이 대부분이고 중간 삽입이 거의 없으니 배열이 훨씬 효율적입니다. 캐시 지역성 측면에서 배열이 10배 이상 빠를 수 있어요."
"LRU 캐시 구현에 HashMap과 이중 연결 리스트 조합을 쓰면 get과 put 모두 O(1)에 처리할 수 있어요. HashMap으로 O(1) 조회를 하고, 이중 연결 리스트로 최근 접근 순서를 관리하는 거죠. 접근된 항목은 리스트 맨 앞으로 이동시키고, 캐시가 가득 차면 맨 뒤 항목을 제거합니다. Java의 LinkedHashMap이 정확히 이 원리입니다."
연결 리스트의 노드들은 힙 메모리 곳곳에 흩어져 할당됩니다. 이로 인해 메모리 단편화가 발생할 수 있고, 각 노드마다 포인터를 위한 추가 메모리(64비트 시스템에서 8바이트)가 필요합니다. 소규모 데이터 저장에는 오히려 배열보다 메모리를 더 많이 사용할 수 있습니다.
현대 CPU는 연속된 메모리를 캐시 라인 단위로 불러옵니다. 배열은 요소들이 연속 배치되어 캐시 적중률이 높지만, 연결 리스트는 노드가 분산되어 캐시 미스가 빈번합니다. 순회 작업에서 배열이 연결 리스트보다 수십 배 빠를 수 있습니다.
연결 리스트 구현 시 빈 리스트, 단일 노드 리스트, head/tail 경계 조건에서 NullPointerException이 자주 발생합니다. 삽입/삭제 로직에서 head, tail, current.next가 null인 경우를 항상 먼저 체크해야 합니다. 단위 테스트로 엣지 케이스를 반드시 커버하세요.